VPN-first ProxyDroid: Compose UI + real Rust tun2socks#40
Merged
Conversation
* HostSocks5ProxyIntegrationTest: instrumentation test that performs a SOCKS5 NO_AUTH handshake from inside the AVD against a host proxy and asserts a 2xx HTTP response. Defaults to 10.0.2.2:1080 (the AVD alias for the host loopback); all knobs overridable via -P testInstrumentationRunnerArguments. * scripts/socks5_test_server.py: stdlib SOCKS5 (NO_AUTH, CONNECT only). * scripts/http_connect_test_server.py: stdlib HTTP CONNECT proxy with optional --auth user:pass for testing the HTTP/HTTPS upstream paths. * README: how to run them. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* app/build.gradle: signingConfigs.release reads keystore creds from local.properties (KEYSTORE_PATH/PASSWORD, KEY_ALIAS/PASSWORD), so assembleRelease produces an installable APK matching the published cert. Falls back to unsigned when local.properties has no keystore (CI). * Delete ProxyDroidService.kt — the legacy iptables-based service is unreachable from the new VPN flow. ProxyDroidReceiver, the widget, and ConnectivityBroadcastReceiver now all funnel through ProxyController. * Delete the obsolete preference XML (replaced by the Compose UI added in the next commit). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Main screen is now a single ComponentActivity hosting Compose: * Connection card with prominent switch + status; 1s poller mirrors the service's working/connecting flags into UI state so the chip flips promptly. * Profile dropdown + add/rename/delete via top-bar overflow menu. * Inline Host/Port fields, Material chip row for proxy type (SOCKS5 / SOCKS4 / HTTP / HTTPS), Authentication accordion (with NTLM domain), Advanced section (PAC, DNS proxy, SSID auto-connect, bypass addresses, per-app routing). BypassListActivity and AppManager rewritten in Compose (LazyColumn + ListItem, search field, FilterChip). FileChooser removed in favour of ActivityResultContracts.OpenDocument / CreateDocument for SAF-based import/export; FileArrayAdapter and utils/Option deleted with it. ProxyDroidVpnService: * Always addDisallowedApplication(packageName) so tun2socks's outbound socket bypasses our own tun (fixes ETIMEDOUT loops). * Handles ACTION_STOP intent: closes the tun, calls stopForeground + stopSelf so the system actually destroys the service. Plain stopService doesn't cut it because the VpnService binding holds the service alive while the tun is open. ProxyController is the shared start/stop helper used by activity, broadcast receivers, and the home-screen widget. start runs VpnService.prepare() and either starts the service directly or routes through the activity for the consent dialog (EXTRA_AUTO_START). Profile gains a copy() method; MainViewModel.updateProfile emits a new Profile instance so MutableStateFlow doesn't drop the emission for the in-place mutation. Default proxyType bumped from "http" to "socks5". Drops the legacy preference XML, the AppCompat sub-screens, and the dead "PLEASE ROOT YOUR DEVICE FIRST" alert. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tion
The pre-existing app/src/main/cpp/tun2socks was a placeholder: it logged
each TCP connection but never wrote any reply packets back to the TUN, never
synthesised SYN-ACKs, and dropped UDP DNS on the floor. Real traffic could
not pass.
This commit vendors the (mihomo-coupled-but-extractable) standalone Rust
tun2socks layer from ../mihomo-android — without the mihomo engine. Full
file list:
* app/src/main/rust/proxydroid-tun2socks/
Cargo.toml — slim deps, no mihomo_*.
src/lib.rs — JNI entry points, parses proxy_type string.
src/tun2socks.rs — netstack-smoltcp + TCP relay; ProxyKind enum
dispatches to one of four upstream connectors:
- SOCKS5 (with optional RFC 1929 user/password)
- SOCKS4 / SOCKS4A
- HTTP CONNECT (with optional Basic auth)
- HTTPS = HTTP CONNECT over TLS to the proxy
(NoVerify cert verifier so private CAs
work; matches the existing DoH posture)
src/dns_table.rs — IP↔hostname map populated from DoH responses.
src/doh_client.rs — DoH resolver routed through the user's upstream
via reqwest, scheme picked by ProxyKind.
src/protect.rs — VpnService.protect(fd) bridge over JNI.
src/logging.rs — android_logger init.
Build wiring:
* build.gradle: add gradlePluginPortal() + rust-android-gradle plugin.
* app/build.gradle: apply the plugin, add cargo {…} block (arm64-v8a only
for now), wire cargoBuild as a dep of the merge tasks, narrow abiFilters
to arm64-v8a accordingly, drop the externalNativeBuild tun2socks subdir.
* app/src/main/cpp/CMakeLists.txt: drop tun2socks subdirectory.
* .github/workflows/android-build.yml: install the Rust toolchain
(aarch64-linux-android target) and cache the cargo registry/target dir.
Tun2SocksHelper.kt updated to:
* loadLibrary("proxydroid_tun2socks").
* New start signature accepting VpnService + proxyType.
* Rust spawns its own tokio runtime, so no Kotlin worker thread needed.
ProxyDroidVpnService.startVpn passes the VpnService (for protect(fd)) and
the upstream's host/port/auth + proxyType. Removes the LocalProxyServer
HTTP→SOCKS bridge entirely (the Rust side now speaks every supported
upstream natively). DNS server advertised by the tun is now 10.0.0.2; UDP/53
is intercepted by the Rust side and forwarded as DoH through the upstream.
APK size: 23 MB → 25 MB (Rust runtime + reqwest/rustls).
Verified end-to-end on Medium_Phone_API_36.1 AVD (arm64) against the host
SOCKS5 and HTTP CONNECT test servers added in the first commit:
* Chrome loads example.com over SOCKS5 → real CONNECT lines in server log.
* Chrome loads example.com over HTTP CONNECT → matching proxy log entries
including DoH (CONNECT 1.1.1.1:443).
SOCKS5+auth, SOCKS4, HTTPS-proxy paths are coded but not exercised live in
this PR (test rig not in place); HTTP-Basic-auth negative case verified.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CI emulator-test job uses an x86_64 emulator and the previous arm64-only abiFilter caused INSTALL_FAILED_NO_MATCHING_ABIS. Now build proxydroid-tun2socks for arm, arm64, x86, x86_64 and ship all of them. * app/build.gradle: cargo.targets = ["arm", "arm64", "x86", "x86_64"]; abiFilters back to all 4. * CI workflow: install all 4 Rust android targets up front. APK size delta is contained because the .so is small (2.2-3.8 MB per ABI). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The real upstream-auth code lives in the Rust tun2socks crate (socks5_handshake, http_connect). Add #[tokio::test] coverage that spins up loopback TCP listeners impersonating auth-requiring SOCKS5 and HTTP CONNECT upstreams, then drives the crate's own handshake functions against them. Six cases: SOCKS5: correct creds, wrong creds, no creds vs auth-required. HTTP CONNECT: correct creds, wrong creds, no creds. Also drop the dead Kotlin LocalProxyServer + its unit tests -- ProxyDroidVpnService no longer routes through it; tun2socks talks to the upstream directly. Fix the stale comment that still referenced LocalProxyServer. cargo fmt incidentally normalized two pre-existing style nits in socks4_handshake. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The existing HostSocks5ProxyIntegrationTest only covered NO_AUTH, and no
emulator-side HTTP CONNECT test existed at all. Extend the rig so the
instrumented suite runs both protocols across {no-auth, auth-ok,
auth-wrong-creds}:
scripts/socks5_test_server.py:
- Add --auth user:pass for RFC 1929 user/password sub-negotiation.
app/src/androidTest/.../HostSocks5ProxyIntegrationTest.kt:
- Existing NO_AUTH test unchanged.
- New auth happy-path test against a user/password upstream.
- New negative test asserting wrong creds get a non-zero RFC 1929
status from the proxy.
app/src/androidTest/.../HostHttpConnectProxyIntegrationTest.kt (new):
- CONNECT through auth-less proxy returns 200.
- CONNECT through auth-required proxy with correct creds returns 200.
- Wrong creds and missing creds both return 407.
scripts/run_emulator_tests.sh:
Was a smoke script that never ran connectedAndroidTest. Rewrite to
spin up all four fake upstream proxies on the host (SOCKS5 + HTTP
CONNECT, each in no-auth and user/pass variants on ports
1080/1081/8081/8082), wait for them to bind, then invoke
connectedDebugAndroidTest with the matching instrumentation args.
Verified locally: ./gradlew :app:compileDebugAndroidTestKotlin succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PRs into feature/** branches and pushes to test/** branches were previously silent. Extend the workflow triggers so this PR (and similar follow-ups) actually run CI. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Google Play has required apps to target API 35 (Android 15) since Nov 1, 2025; the current targetSdk 33 means uploads are rejected. Move to 35 across the board and raise the floor to API 24 (Android 7.0), which covers ~97% of active devices and matches the practical minimum required by current AndroidX / Compose dependencies. AGP 8.1.2 caps compileSdk at 34, so the toolchain has to move too. Pick the smallest delta that supports compileSdk 35: - AGP 8.1.2 -> 8.5.0 - Gradle wrapper 8.4 -> 8.7 - Kotlin 1.9.10 -> 1.9.25 - Compose Compiler ext 1.5.3 -> 1.5.15 (matches Kotlin 1.9.25) - compileSdk 34 -> 35 - minSdk 21 -> 24 - targetSdk 33 -> 35 JDK requirement (17) unchanged; NDK 25.1.8937393 unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two fixes bundled on the SDK-bump branch: 1. Mirrors: switch the Maven repos to Google's China mirror (https://dl.google.cn/dl/android/maven2/), Aliyun for Central and gradle-plugins, and Tencent Cloud for the Gradle binary distribution. Originals kept as fallbacks. Builds now work from networks where dl.google.com / services.gradle.org are unreachable directly. 2. Build failure: AGP 8.5 strictly rejects duplicated jniLibs sources, but rust-android-gradle 0.9.6 registers its build/rustJniLibs/<abi> output into jniLibs.srcDirs while the same path is also captured by AGP via the cargoBuild task, making mergeDebugJniLibFolders fail with "Duplicate resources" for libproxydroid_tun2socks.so on all four ABIs. Add a packagingOptions.jniLibs.pickFirsts rule for the .so. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The pickFirsts approach applies at packaging, but the failure happens earlier in mergeJniLibFolders. Switch to setting duplicatesStrategy = EXCLUDE on those merge Copy tasks, which is the well-known workaround for rust-android-gradle 0.9.6 on AGP 8.5+. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
MergeSourceSetFolders does not expose duplicatesStrategy. The actual fix is to dedupe android.sourceSets.*.jniLibs.srcDirs at configure time so each ABI's libproxydroid_tun2socks.so is only listed once. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The compileSdk 35 path forced AGP 8.5.0, which in turn broke mergeJniLibFolders against rust-android-gradle 0.9.6 with "Duplicate resources" for every ABI's libproxydroid_tun2socks.so. Neither packagingOptions.jniLibs.pickFirsts (wrong phase) nor srcDirs dedupe (duplicates come from AGP's auto-registration of cargoBuild task outputs, not from srcDirs) resolves it, and no released rust-android-gradle plugin is compatible with AGP 8.5+. Sidestep the whole upgrade: AGP only requires compileSdk to be high enough to reference APIs the code actually uses; targetSdk is a manifest declaration Play reads, and targetSdk > compileSdk is allowed. Set targetSdk to 35 (Play's current floor) on top of the existing toolchain: - AGP stays 8.1.2 - Gradle stays 8.4 - Kotlin stays 1.9.10 - Compose Compiler ext stays 1.5.3 - compileSdk stays 34 - minSdk 21 -> 24 - targetSdk 33 -> 35 Mirror changes (dl.google.cn, Aliyun, Tencent Cloud) are kept. Drop the AGP-8.5-specific JNI dedupe workaround. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
mirrors.cloud.tencent.com timed out from GitHub Actions runners. services.gradle.org works from CI and is reachable from the user's local network via their existing HTTPS proxy. Maven repo mirrors (dl.google.cn / Aliyun) stay since they don't affect CI. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`./gradlew connectedDebugAndroidTest` on AGP 8.1.2 + JDK 17 dies with
java.lang.IllegalAccessError: class com.google.protobuf.GeneratedMessageV3
tried to access method
com.google.protobuf.CodedInputStream.shouldDiscardUnknownFields()
(GeneratedMessageV3 in URLClassLoader; CodedInputStream in loader 'app')
before any test runs (tests=0 in the JUnit XML, APK uninstall failures
in the UTP cleanup phase). Root cause: UTP host plugins live in their
own URLClassLoader while ddmlib lives in Gradle's "app" classloader,
and each pulls its own protobuf-java version; the JVM treats the two
copies as distinct types and the cross-classloader method dispatch
fails.
AGP 8.1.2 has no `useUnifiedTestPlatform=false` opt-out (Google
removed that flag in 8.x), and the obvious "upgrade AGP" path
retriggers the rust-android-gradle 0.9.6 mergeJniLibFolders
duplicate-resources bug we already had to dodge.
Bypass UTP entirely with `adb shell am instrument -w` -- the same
path CI's android-emulator-runner action uses, which is why CI runs
the suite green while local does not. The script:
- Builds debug + androidTest APKs via gradle (no UTP touched).
- Auto-picks free ports if 1080/1081/8081/8082 collide locally
(e.g. sslocal already on 8081).
- Boots the four fake-upstream Python proxies on the host.
- Reinstalls both APKs on the emulator.
- Runs the instrumentation directly via adb am instrument.
- Parses the INSTRUMENTATION_STATUS_CODE / INSTRUMENTATION_CODE
stream to report PASS / FAIL.
- Tears everything down on exit.
Verified locally on AVD meow_api35 (arm64-v8a, API 35): 8/8 tests
green in 0.4 s of test time.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Google Play requires new apps to target API 36 (Android 16) by Aug 31, 2025 and updates by Nov 1, 2025. Previous target 35 satisfied the 2024 deadline but trips the 2025 warning in Play Console. AGP 8.1.2 only officially supports compileSdk 34, so set android.suppressUnsupportedCompileSdk=36 to silence the build-time error. Bumping AGP itself is off the table for now — it retriggers the rust-android-gradle 0.9.6 mergeJniLibFolders duplicate-resources bug we already had to work around (see 0249f91 / 8875c74). minSdk stays at 24 (no Play policy bump there yet). Verified locally on AVD meow_api35 (arm64-v8a, API 35): - ./gradlew :app:assembleDebug green - scripts/run_local_emulator_tests.sh: 8/8 tests green Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
app/src/main/cpp/tun2socks/(which never wrote replies back to the TUN) with a vendored, slimmed Rust crate built onnetstack-smoltcp. Supports SOCKS5 (with RFC 1929 user/password), SOCKS4/SOCKS4A, HTTP CONNECT (with Basic auth), and HTTPS (HTTP CONNECT over TLS to the proxy). UDP DNS is intercepted and forwarded as DoH through the same upstream.MainActivity(still namedProxyDroid) replaces the 800-linePreferenceActivity. Big connection card with the switch + live status, profile dropdown, inline Host/Port + chip-row proxy type, Authentication accordion, Advanced section.BypassListActivityandAppManagerrewritten in Compose;FileChooserreplaced with the system Storage Access Framework.ProxyDroidService, the root check / "PLEASE ROOT YOUR DEVICE FIRST" alert, and the unusedLocalProxyServerHTTP→SOCKS bridge. New sharedProxyControlleris the start/stop helper used by activity, broadcast receivers, and the home-screen widget.ProxyDroidVpnServicenow adds itself toaddDisallowedApplicationso tun2socks's outbound socket bypasses the tun, and handles anACTION_STOPintent so disconnect actually destroys the service (plainstopServicedoesn't, because theVpnServiceframework binding holds it alive).HostSocks5ProxyIntegrationTest(instrumentation, runs from the AVD against a host SOCKS5), plus two stdlib Python proxies inscripts/(socks5_test_server.py,http_connect_test_server.pywith optional--auth)..github/workflows/android-build.ymlnow installs the Rust toolchain (aarch64-linux-android target) and caches the cargo registry/target dir before each Gradle invocation.What's verified end-to-end on
Medium_Phone_API_36.1(arm64)http_connect_test_server.pyNoVerifycert verifier so private CAs work), no live test rigBuild / size
compileSdkbumped 33 → 34 (Compose BOM 2023.10.01 / activity-compose 1.8.0 require it).targetSdkstays 33.abiFiltersnarrowed toarm64-v8afor now; the cargo crate builds cleanly for the other 3 Android targets too — re-add them once the Rust targets are installed in CI.Caveats
values-fr/pt/ru) lag English for any new keys.ProxyController.startWithConsent, which falls back to launching the activity for the VPN consent dialog when consent isn't yet granted — same UX as every other VPN app.Commits
Test plan
./gradlew :app:assembleDebug :app:lintRelease :app:testReleaseUnitTest(local, green)./gradlew :app:assembleRelease(local, green; signed APK)🤖 Generated with Claude Code